Explorez le rendu forward groupé (Clustered Forward Rendering) en WebGL, une technique puissante pour le rendu de centaines de lumières dynamiques en temps réel. Découvrez les concepts clés et les stratégies d'optimisation.
Libérer la performance : une immersion dans le rendu forward groupé de WebGL et l'optimisation de l'indexation des lumières
Dans le monde de l'infographie 3D temps réel sur le web, le rendu de nombreuses lumières dynamiques a toujours été un défi de performance majeur. En tant que développeurs, nous nous efforçons de créer des scènes plus riches et plus immersives, mais chaque source de lumière supplémentaire peut augmenter de manière exponentielle le coût de calcul, poussant WebGL à ses limites. Les techniques de rendu traditionnelles forcent souvent un choix difficile : sacrifier la fidélité visuelle pour la performance, ou accepter des fréquences d'images plus basses. Mais et s'il existait un moyen d'avoir le meilleur des deux mondes ?
Voici le Rendu Forward Groupé (Clustered Forward Rendering), aussi connu sous le nom de Forward+. Cette technique puissante offre une solution sophistiquée, combinant la simplicité et la flexibilité matérielle du rendu forward traditionnel avec l'efficacité d'éclairage du rendu différé (deferred shading). Elle nous permet de rendre des scènes avec des centaines, voire des milliers, de lumières dynamiques tout en maintenant des fréquences d'images interactives.
Cet article propose une exploration complète du Rendu Forward Groupé dans le contexte de WebGL. Nous allons décortiquer les concepts fondamentaux, de la subdivision du tronc de vision (view frustum) à la sélection des lumières (culling), et nous concentrer intensément sur l'optimisation la plus critique : le pipeline de données d'indexation des lumières. C'est le mécanisme qui communique efficacement quelles lumières affectent quelles parties de l'écran, du CPU au fragment shader du GPU.
Le paysage du rendu : Forward vs. Deferred
Pour comprendre pourquoi le rendu groupé est si efficace, nous devons d'abord saisir les limites des méthodes qui l'ont précédé.
Rendu Forward Traditionnel
C'est l'approche de rendu la plus directe. Pour chaque objet, le vertex shader traite ses sommets, et le fragment shader calcule la couleur finale pour chaque pixel. En ce qui concerne l'éclairage, le fragment shader boucle généralement sur chaque lumière de la scène et accumule sa contribution. Le problème principal est sa faible capacité de mise à l'échelle. Le coût de calcul est à peu près proportionnel à (Nombre de Fragments) x (Nombre de Lumières). Avec seulement quelques dizaines de lumières, les performances peuvent chuter, car chaque pixel vérifie de manière redondante chaque lumière, même celles situées à des kilomètres ou derrière un mur.
Rendu Différé (Deferred Shading)
Le Rendu Différé a été développé pour résoudre précisément ce problème. Il découple la géométrie de l'éclairage dans un processus en deux passes :
- Passe de Géométrie : La géométrie de la scène est rendue dans plusieurs textures plein écran collectivement appelées le G-buffer. Ces textures stockent des données comme la position, les normales et les propriétés des matériaux (par ex., albedo, rugosité) pour chaque pixel.
- Passe d'Éclairage : Un quad plein écran est dessiné. Pour chaque pixel, le fragment shader échantillonne le G-buffer pour reconstruire les propriétés de la surface, puis calcule l'éclairage. L'avantage clé est que l'éclairage n'est calculé qu'une seule fois par pixel, et il est facile de déterminer quelles lumières affectent ce pixel en fonction de sa position dans le monde.
Bien que très efficace pour les scènes avec de nombreuses lumières, le rendu différé a ses propres inconvénients, en particulier pour WebGL. Il a des exigences élevées en bande passante mémoire à cause du G-buffer, éprouve des difficultés avec la transparence (qui nécessite une passe de rendu forward distincte), et complique l'utilisation des techniques d'anticrénelage comme le MSAA.
Le compromis idéal : Forward+
Le Rendu Forward Groupé offre un compromis élégant. Il conserve la nature en une seule passe et la flexibilité matérielle du rendu forward, mais incorpore une étape de pré-traitement pour réduire considérablement le nombre de calculs d'éclairage par fragment. Il évite le lourd G-buffer, le rendant plus économe en mémoire et compatible d'emblée avec la transparence et le MSAA.
Concepts Fondamentaux du Rendu Forward Groupé
L'idée centrale du rendu groupé est d'être plus intelligent sur les lumières que nous vérifions. Au lieu que chaque pixel vérifie chaque lumière, nous pouvons prédéterminer quelles lumières sont suffisamment proches pour potentiellement affecter une région de l'écran, et faire en sorte que les pixels de cette région ne vérifient que ces lumières.
Ceci est réalisé en subdivisant le tronc de vision (view frustum) de la caméra en une grille 3D de volumes plus petits appelés clusters (ou tuiles).
Le processus global peut être décomposé en quatre étapes principales :
- 1. Création de la Grille de Clusters : Définir et construire une grille 3D qui partitionne le tronc de vision. Cette grille est fixe dans l'espace de vue et se déplace avec la caméra.
- 2. Assignation des Lumières (Culling) : Pour chaque cluster de la grille, déterminer une liste de toutes les lumières dont les volumes d'influence s'intersectent avec lui. C'est l'étape cruciale de sélection (culling).
- 3. Indexation des Lumières : C'est notre point de mire. Nous empaquetons les résultats de l'étape d'assignation des lumières dans une structure de données compacte qui peut être efficacement envoyée au GPU et lue par le fragment shader.
- 4. Shading : Pendant la passe de rendu principale, le fragment shader détermine d'abord à quel cluster il appartient. Il utilise ensuite les données d'indexation des lumières pour récupérer la liste des lumières pertinentes pour ce cluster et effectue les calculs d'éclairage *uniquement* pour ce petit sous-ensemble de lumières.
Plongée en profondeur : la construction de la grille de clusters
Le fondement de la technique est une grille bien structurée. Les choix faits ici ont un impact direct à la fois sur l'efficacité de la sélection et sur les performances.
Définition des dimensions de la grille
La grille est définie par sa résolution sur les axes X, Y et Z (par ex., 16x9x24 clusters). Le choix des dimensions est un compromis :
- Haute Résolution (Plus de clusters) : Conduit à une sélection de lumières plus précise et plus stricte. Moins de lumières seront assignées par cluster, ce qui signifie moins de travail pour le fragment shader. Cependant, cela augmente la surcharge de l'étape d'assignation des lumières sur le CPU et l'empreinte mémoire des structures de données des clusters.
- Basse Résolution (Moins de clusters) : Réduit la surcharge côté CPU et mémoire, mais entraîne une sélection plus grossière. Chaque cluster est plus grand, il intersectera donc plus de lumières, menant à plus de travail dans le fragment shader.
Une pratique courante consiste à lier les dimensions X et Y au ratio d'aspect de l'écran, par exemple, en divisant l'écran en 16x9 tuiles. La dimension Z est souvent la plus critique à régler.
Découpage en Z logarithmique : une optimisation critique
Si nous divisons la profondeur du frustum (axe Z) en tranches linéaires, nous rencontrons un problème lié à la projection perspective. Une grande quantité de détails géométriques est concentrée près de la caméra, tandis que les objets éloignés occupent très peu de pixels. Une division linéaire en Z créerait des clusters grands et imprécis près de la caméra (là où la précision est la plus nécessaire) et des clusters minuscules et inutiles au loin.
La solution est le découpage en Z logarithmique (ou exponentiel). Cela crée des clusters plus petits et plus précis près de la caméra et des clusters progressivement plus grands plus loin, alignant la distribution des clusters avec le fonctionnement de la projection perspective. Cela garantit un nombre plus uniforme de fragments par cluster et conduit à une sélection beaucoup plus efficace.
Une formule pour calculer la profondeur `z` pour la i-ème tranche sur `N` tranches totales, étant donné le plan proche `n` et le plan lointain `f`, peut s'exprimer comme suit :
z_i = n * (f/n)^(i/N)Cette formule garantit que le ratio des profondeurs de tranches consécutives est constant, créant la distribution exponentielle souhaitée.
Le cœur du sujet : sélection et indexation des lumières
C'est ici que la magie opère. Une fois notre grille définie, nous devons déterminer quelles lumières affectent quels clusters, puis empaqueter ces informations pour le GPU. En WebGL, cette logique de sélection des lumières est généralement exécutée sur le CPU en utilisant JavaScript à chaque image où les lumières ou la caméra se déplacent.
Tests d'intersection lumière-cluster
Le processus est conceptuellement simple : boucler sur chaque lumière et la tester pour une intersection avec le volume englobant de chaque cluster. Le volume englobant d'un cluster est lui-même un frustum. Les tests courants incluent :
- Lumières ponctuelles : Traitées comme des sphères. Le test est une intersection sphère-frustum.
- Projecteurs (Spot Lights) : Traités comme des cônes. Le test est une intersection cône-frustum, ce qui est plus complexe.
- Lumières directionnelles : Celles-ci sont souvent considérées comme affectant tout, elles sont donc généralement gérées séparément et non incluses dans le processus de sélection.
Exécuter ces tests efficacement est essentiel. Après cette étape, nous avons une correspondance, peut-être dans un tableau de tableaux JavaScript, comme : clusterLights[clusterId] = [lightId1, lightId2, ...].
Le défi de la structure de données : du CPU au GPU
Comment faire passer cette liste de lumières par cluster au fragment shader ? Nous ne pouvons pas simplement passer un tableau de longueur variable. Le shader a besoin d'un moyen prévisible pour rechercher ces données. C'est là que l'approche de la Liste Globale de Lumières et de la Liste d'Index de Lumières entre en jeu. C'est une méthode élégante pour aplatir notre structure de données complexe en textures adaptées au GPU.
Nous créons deux structures de données principales :
- Une Texture de Grille d'Informations sur les Clusters : C'est une texture 3D (ou une texture 2D émulant une 3D) où chaque texel correspond à un cluster de notre grille. Chaque texel stocke deux informations vitales :
- Un décalage (offset) : C'est l'indice de départ dans notre deuxième structure de données (la Liste Globale de Lumières) où commencent les lumières pour ce cluster.
- Un compte (count) : C'est le nombre de lumières qui affectent ce cluster.
- Une Texture de Liste Globale de Lumières : C'est une simple liste 1D (stockée dans une texture 2D) contenant une séquence concaténée de tous les indices de lumières pour tous les clusters.
Visualisation du flux de données
Imaginons un scénario simple :
- Le cluster 0 est affecté par les lumières d'indices [5, 12].
- Le cluster 1 est affecté par les lumières d'indices [8, 5, 20].
- Le cluster 2 est affecté par la lumière d'indice [7].
Liste Globale de Lumières : [5, 12, 8, 5, 20, 7, ...]
Grille d'Informations sur les Clusters :
- Texel pour le Cluster 0 :
{ offset: 0, count: 2 } - Texel pour le Cluster 1 :
{ offset: 2, count: 3 } - Texel pour le Cluster 2 :
{ offset: 5, count: 1 }
Implémentation en WebGL & GLSL
Maintenant, connectons les concepts au code. L'implémentation implique une partie JavaScript pour la sélection et la préparation des données, et une partie GLSL pour le shading.
Transfert de données vers le GPU (JavaScript)
Après avoir effectué la sélection des lumières sur le CPU, vous aurez vos données de grille de clusters (paires décalage/compte) et votre liste globale de lumières. Celles-ci doivent être téléversées sur le GPU à chaque image.
- Empaqueter et Téléverser les Données des Clusters : Créez un `Float32Array` ou `Uint32Array` pour vos données de clusters. Vous pouvez empaqueter le décalage et le compte pour chaque cluster dans les canaux RG d'une texture. Utilisez `gl.texImage2D` pour créer ou `gl.texSubImage2D` pour mettre à jour une texture avec ces données. Ce sera votre texture de Grille d'Informations sur les Clusters.
- Téléverser la Liste Globale de Lumières : De même, aplatissez vos indices de lumières dans un `Uint32Array` et téléversez-le dans une autre texture.
- Téléverser les Propriétés des Lumières : Toutes les données des lumières (position, couleur, intensité, rayon, etc.) devraient être stockées dans une grande texture ou un Uniform Buffer Object (UBO) pour des recherches indexées rapides depuis le shader.
La logique du Fragment Shader (GLSL)
Le fragment shader est l'endroit où les gains de performance sont réalisés. Voici la logique étape par étape :
Étape 1 : Déterminer l'index de cluster du fragment
D'abord, nous devons savoir dans quel cluster se trouve le fragment actuel. Cela nécessite sa position dans l'espace de vue.
// Uniforms fournissant les informations de la grille
uniform vec3 u_gridDimensions; // ex., vec3(16.0, 9.0, 24.0)
uniform vec2 u_screenDimensions;
uniform float u_nearPlane;
uniform float u_farPlane;
// Fonction pour obtenir l'index de tranche Z à partir de la profondeur en espace de vue
float getClusterZIndex(float viewZ) {
// viewZ est négatif, le rendre positif
viewZ = -viewZ;
// L'inverse de la formule logarithmique que nous avons utilisée sur le CPU
float slice = floor(log(viewZ / u_nearPlane) / log(u_farPlane / u_nearPlane) * u_gridDimensions.z);
return slice;
}
// Logique principale pour obtenir l'index 3D du cluster
vec3 getClusterIndex() {
// Obtenir l'index X et Y à partir des coordonnées de l'écran
float clusterX = floor(gl_FragCoord.x / u_screenDimensions.x * u_gridDimensions.x);
float clusterY = floor(gl_FragCoord.y / u_screenDimensions.y * u_gridDimensions.y);
// Obtenir l'index Z à partir de la position Z du fragment en espace de vue (v_viewPos.z)
float clusterZ = getClusterZIndex(v_viewPos.z);
return vec3(clusterX, clusterY, clusterZ);
}
Étape 2 : Récupérer les données du cluster
En utilisant l'index du cluster, nous échantillonnons notre texture de Grille d'Informations sur les Clusters pour obtenir le décalage et le compte pour la liste de lumières de ce fragment.
uniform sampler2D u_clusterTexture; // Texture stockant le décalage et le compte
// ... dans main() ...
vec3 clusterIndex = getClusterIndex();
// Aplatir l'index 3D en coordonnée de texture 2D si nécessaire
vec2 clusterTexCoord = ...;
vec2 lightData = texture2D(u_clusterTexture, clusterTexCoord).rg;
int offset = int(lightData.x);
int count = int(lightData.y);
Étape 3 : Boucler et accumuler l'éclairage
Ceci est l'étape finale. Nous exécutons une boucle courte et délimitée. Pour chaque itération, nous récupérons un indice de lumière depuis la Liste Globale de Lumières, puis utilisons cet indice pour obtenir les propriétés complètes de la lumière et calculer sa contribution.
uniform sampler2D u_globalLightIndexTexture;
uniform sampler2D u_lightPropertiesTexture; // Un UBO serait mieux
vec3 finalColor = vec3(0.0);
for (int i = 0; i < count; i++) {
// 1. Obtenir l'index de la lumière à traiter
int lightIndex = int(texture2D(u_globalLightIndexTexture, vec2(float(offset + i), 0.0)).r);
// 2. Récupérer les propriétés de la lumière en utilisant cet index
Light currentLight = getLightProperties(lightIndex, u_lightPropertiesTexture);
// 3. Calculer la contribution de cette lumière
finalColor += calculateLight(currentLight, surfaceProperties, viewDir);
}
Et c'est tout ! Au lieu d'une boucle s'exécutant des centaines de fois, nous avons maintenant une boucle qui peut s'exécuter 5, 10 ou 30 fois, selon la densité de lumières dans cette partie spécifique de la scène, conduisant à une amélioration monumentale des performances.
Optimisations avancées et considérations futures
- CPU vs. Compute : Le principal goulot d'étranglement de cette technique en WebGL est que la sélection des lumières se fait sur le CPU en JavaScript. C'est mono-threadé et nécessite une synchronisation des données avec le GPU à chaque image. L'arrivée de WebGPU change la donne. Ses compute shaders permettront de décharger tout le processus de construction des clusters et de sélection des lumières sur le GPU, le rendant parallèle et des ordres de grandeur plus rapide.
- Gestion de la mémoire : Soyez attentif à la mémoire utilisée par vos structures de données. Pour une grille de 16x9x24 (3 456 clusters) et un maximum de, disons, 64 lumières par cluster, la liste globale de lumières pourrait potentiellement contenir 221 184 indices. Ajuster votre grille et définir un maximum réaliste de lumières par cluster est essentiel.
- Réglage de la grille : Il n'y a pas de nombre magique unique pour les dimensions de la grille. La configuration optimale dépend fortement du contenu de votre scène, du comportement de la caméra et du matériel cible. Le profilage et l'expérimentation avec différentes tailles de grille sont cruciaux pour atteindre des performances de pointe.
Conclusion
Le Rendu Forward Groupé est plus qu'une simple curiosité académique ; c'est une solution pratique et puissante à un problème important de l'infographie web en temps réel. En subdivisant intelligemment l'espace de vue et en effectuant une étape de sélection et d'indexation des lumières hautement optimisée, il brise le lien direct entre le nombre de lumières et le coût du fragment shader.
Bien qu'il introduise plus de complexité côté CPU par rapport au rendu forward traditionnel, le gain de performance est immense, permettant des expériences plus riches, plus dynamiques et visuellement captivantes directement dans le navigateur. Le cœur de son succès réside dans le pipeline efficace d'indexation des lumières — le pont qui transforme un problème spatial complexe en une boucle simple et délimitée sur le GPU.
Alors que la plateforme web évolue avec des technologies comme WebGPU, des techniques comme le Rendu Forward Groupé ne deviendront que plus accessibles et performantes, estompant davantage les frontières entre les applications 3D natives et celles basées sur le web.